Skip to content

feat: a real call stack, shared by the editor and the CLI - #15

Merged
RaoulSchaffranek merged 13 commits into
mainfrom
feat/call-stack
Aug 21, 2026
Merged

feat: a real call stack, shared by the editor and the CLI#15
RaoulSchaffranek merged 13 commits into
mainfrom
feat/call-stack

Conversation

@RaoulSchaffranek

Copy link
Copy Markdown
Member

The Callstack view showed one frame, named after a wasm instruction and carrying the replay position in its label. It now shows every frame that led to the current line, and soroban-trace reports the same stack per stop as frames — both from one derivation, debugAdapter/callStack.ts, so a script and a debug session can never disagree about who called whom.

The stack is assembled from three sources, ordered by how much each can be trusted, and every frame states which one placed it:

The trace's own wasm activations are the STRUCTURE. computeDepths becomes computeFrames, which returns the linked activation stack (fn, depth, callSite, caller) the depths were always a projection of; depth is now that projection, so the frame count and step-over/ step-out are derived from the same walk and cannot drift apart. An outer frame stands at the call it is suspended in, which is also the record its own locals were last observed at.

DWARF adds the Rust frames inlining erased. ScopeIndex.inlineScopesAt walks the DW_TAG_inlined_subroutine instances covering a pc, resolving names through abstract_origin/specification/linkage_name and call sites through call_file/call_line; LineTable keeps each unit's file table so a call-site file INDEX resolves to a path. An instance whose range this parser cannot read (a v5 .debug_rnglists list) is skipped, never guessed at: a missing frame degrades the view, an invented one misreports the program. The trace's contract-call boundaries close the stack below as labels.

Names come off a precision ladder: the DWARF subprogram name qualified by its enclosing namespaces and types, else the module's name-section symbol demangled (new wasm/names.ts — rustc leaves some method DIEs anonymous, so this rung matters even in a DWARF build), else the function index, else the raw offset. A frame is never nameless, and a release build with no debug info still gets control::Control::while_call+0x1a.

Frames are inspectable per frame, not just at the top: a variables reference now encodes the frame together with the scope kind, so Locals, Value Stack and Variables answer for the SELECTED frame, read from that frame's own record. Linear memory stays at the cursor — a callee may have written through a reference the caller still holds. stackTrace honors the client's paging window, gives each frame its own id and instruction pointer reference (so Disassembly follows the selection), and reports a non-workspace or sourceless frame as subtle rather than dropping it: an optimized build can put eight SDK conversion frames between the user's code and the pc, and a stack that quietly hid them would be a lie about how the program got here.

The replay position moves out of the frame name into the thread label (soroban-vm [29/40]). A frame name says what the program is doing; where the cursor sits is a property of the recorded thread.

The rules are specified as C1-C8 in the new docs/callstack.md, pinned by test/callStack.test.ts and test/dapFrames.test.ts across the fixture spread that matters: opt-0, optimized, stripped of DWARF, without a name section, and with no wasm at all.

The Callstack view showed one frame, named after a wasm instruction and
carrying the replay position in its label. It now shows every frame that
led to the current line, and `soroban-trace` reports the same stack per
stop as `frames` — both from one derivation, `debugAdapter/callStack.ts`,
so a script and a debug session can never disagree about who called whom.

The stack is assembled from three sources, ordered by how much each can
be trusted, and every frame states which one placed it:

The trace's own wasm activations are the STRUCTURE. `computeDepths`
becomes `computeFrames`, which returns the linked activation stack
(`fn`, `depth`, `callSite`, `caller`) the depths were always a projection
of; depth is now that projection, so the frame count and step-over/
step-out are derived from the same walk and cannot drift apart. An outer
frame stands at the call it is suspended in, which is also the record its
own locals were last observed at.

DWARF adds the Rust frames inlining erased. `ScopeIndex.inlineScopesAt`
walks the DW_TAG_inlined_subroutine instances covering a pc, resolving
names through abstract_origin/specification/linkage_name and call sites
through call_file/call_line; LineTable keeps each unit's file table so a
call-site file INDEX resolves to a path. An instance whose range this
parser cannot read (a v5 .debug_rnglists list) is skipped, never guessed
at: a missing frame degrades the view, an invented one misreports the
program. The trace's contract-call boundaries close the stack below as
labels.

Names come off a precision ladder: the DWARF subprogram name qualified by
its enclosing namespaces and types, else the module's `name`-section
symbol demangled (new wasm/names.ts — rustc leaves some method DIEs
anonymous, so this rung matters even in a DWARF build), else the function
index, else the raw offset. A frame is never nameless, and a release
build with no debug info still gets `control::Control::while_call+0x1a`.

Frames are inspectable per frame, not just at the top: a variables
reference now encodes the frame together with the scope kind, so Locals,
Value Stack and Variables answer for the SELECTED frame, read from that
frame's own record. Linear memory stays at the cursor — a callee may have
written through a reference the caller still holds. `stackTrace` honors
the client's paging window, gives each frame its own id and instruction
pointer reference (so Disassembly follows the selection), and reports a
non-workspace or sourceless frame as `subtle` rather than dropping it: an
optimized build can put eight SDK conversion frames between the user's
code and the pc, and a stack that quietly hid them would be a lie about
how the program got here.

The replay position moves out of the frame name into the thread label
(`soroban-vm [29/40]`). A frame name says what the program is doing;
where the cursor sits is a property of the recorded thread.

The rules are specified as C1-C8 in the new docs/callstack.md, pinned by
test/callStack.test.ts and test/dapFrames.test.ts across the fixture
spread that matters: opt-0, optimized, stripped of DWARF, without a name
section, and with no wasm at all.
The extension is published as `stellar-debugger`, so the identifiers a user
types or reads should not say `soroban`. Every one of them is a breaking change
after the first public release and free before it, which is why they all move
now: the debug type is `"type": "stellar"`, the settings are
`stellar.kometNode.path` and `stellar.cliPath` (the old `soroban.stellar.path`
read badly under any product name), the command is `stellar.debug`, the CLIs are
`stellar-trace` and `stellar-dap`, and the thread label is `stellar-vm [n/m]`.
Launch-config names, snippets, error messages, docs and examples follow.

Internal identifiers deliberately keep the name — `SorobanDebugSession`,
`SorobanLaunchArgs`, `src/soroban/**` — because they refer to the Soroban
protocol layer rather than to the product, and renaming them would churn the
tree without changing anything a user sees. Prose keeps "Soroban" wherever it
names the platform.

The CLI docs also state that the marketplace build does not install
`stellar-trace` or `stellar-dap`; they are built from this repository.
Marketplace metadata the listing needs: a 128x128 icon (generated from
`images/icon.svg`, regeneration documented in `images/README.md`), a gallery
banner, keywords, a Testing category alongside Debuggers, and `preview: true`
for a first release. The version is 0.1.0.

`.vscodeignore` becomes an allowlist — ignore everything, then add back `dist/`,
the icon and the four metadata files. A denylist is a trap here: `vsce` packages
from the disk rather than from git, and `examples/*/target` plus
`test/fixtures/*/target` are roughly 6 GB of gitignored Rust build output. `vsce
ls` now reports 8 files and the package is 905 KB. CI packages on every run and
fails if the `.vsix` grows past 2 MB, so a regression cannot pass unnoticed.

`release.yml` is tag-driven: it refuses a tag that disagrees with
`package.json`, runs the suite with the e2e opt-out (CI has already run it
against the real node on that commit), then publishes to the VS Code Marketplace
and to Open VSX, which is where Cursor, Windsurf and VSCodium install from, and
attaches the `.vsix` to a GitHub release. It needs the `VSCE_PAT` and `OVSX_PAT`
secrets.

Activation was the blanket `onDebug`, which woke the extension for any debug
session and left the palette command relying on implicit activation; it is now
`onDebugResolve:stellar` plus an explicit `onCommand:stellar.debug`.

RELEASE-CHECKLIST.md tracks what is left before the tag and is meant to be
deleted once the release is out.
The changelog was written for contributors: it explained DWARF, wasm
activations, `contractspecv0`, opt-level 0 and the trace's `kind` field, none of
which tells a user what they can now do. It is rewritten as 498 words instead of
1150, every entry a full sentence about observable behaviour, with the
mechanism left to `docs/` and CONTRIBUTING. The `[Unreleased]` section is folded
into `[0.1.0]`, and the entry for the never-released 0.0.1 is gone along with
its dead tag link.

The README gains an Install section (it previously said "install the extension"
with no link), states that komet-node must be built with komet v0.1.87 or newer,
and gains a Known limitations section covering partial traces, the opt-level-0
requirement for source stepping, and one traced transaction per session. Its
Roadmap no longer contradicts its Features: the Variables view ships, inline
values are what is still future. `examples/` is described as a repo clone rather
than as bundled, since it does not ship in the `.vsix`.

CONTRIBUTING documents the release process, the allowlist `.vscodeignore`
invariant, and the decision to defer the ESLint 9 migration: ESLint 8 is
end-of-life but dev-only, so it never reaches the `.vsix`.

SECURITY.md and CODE_OF_CONDUCT.md are the two files a public repository is
measured against and were missing. SECURITY.md states scope explicitly,
including that a `launch.json` naming a malicious build command is not a
vulnerability in this extension.
`.gitignore` now covers `.env`, `.env.*` and `.deps/`, all of which exist
untracked in a working tree, so no secret or three-repo checkout can be
committed by accident.

`.vscode/launch.json` carried two personal configurations pointing at
`/home/node/work/...`, and `test/justMyCode.test.ts` classified paths under
`/home/node/work/rs-lending-xlm/...`, naming an internal project in a repository
about to go public. The test's ground-truth paths are now neutral; the
classifier keys off the `.rustup`, `.cargo/registry` and `/rustc/` markers, so it
treats them identically. The stray `state.kore` in the root is deleted.
A user without one of the external dependencies previously read a symptom
rather than a cause. A missing komet-node spent sixty silent seconds polling a
port nothing was listening on and then blamed the health check, though the
`ENOENT` was known at second zero and logged where nobody looks. A missing
Stellar CLI produced `build command exited with code 127`. A komet-node older
than komet v0.1.87 built, deployed and invoked the contract before failing with
`trace line 1: 'kind' must be a non-empty string`, which reads as a corrupt file
rather than as a version to upgrade.

`src/diagnostics/setup.ts` now owns those messages. Each names what could not be
done, why, the command that installs the missing piece and the setting that
points at it, and closes with a link to the README's new Troubleshooting
section — appended by the `SetupError` constructor, so no caller can forget it.
The module is pure and free of `vscode`, so the same wording reaches the editor
modal, the debug console, `stellar-trace` and `stellar-dap`.

Behaviour, not only wording, changes in three places. `KometProcess` records a
failed spawn or an early exit and exposes `whenFailed()`, which `LiveBackend`
races against the health check: a node that cannot start now fails the launch at
once, and one that dies during boot is reported with its exit code and its own
last output instead of as a timeout. `ContractBuilder` keeps a bounded tail of
the build output and classifies a non-zero exit from it — a missing Stellar CLI,
a missing Rust toolchain, a missing wasm target, a command that is not the
Stellar CLI, or an ordinary compile failure — because an exit code cannot tell
those apart. A trace record without `kind` raises `StaleTraceError`, still a
`TraceParseError` so existing handlers are unaffected, naming komet v0.1.87.

A missing program the output does not identify is named without a guess at which
dependency it belongs to: telling someone their missing `foo` is a missing
Stellar CLI would be a wrong answer stated confidently.

`waitForHealthy` takes a `giveUp` predicate so the poll loop stops when the node
is already known to be doomed, `node.healthTimeoutMs` makes the health deadline
configurable (and the failure path testable), and both CLIs print a setup error
as its message alone — a stack trace in front of an explanation only buries the
fix.
The Requirements section named komet-node and linked its repository, but the
repository's own README does not say how to install it either — the reader was
left to discover that it comes from `kup`, and that `kup` comes from Nix. The
three commands are the ones `.devcontainer/Dockerfile` already runs, so the
README and the container cannot drift apart.

The `nix.conf` stanza the Dockerfile writes is deliberately left out: it exists
only because the image build has stdin closed, where kup's prompt to register
the substituters dies on EOFError. A human running this interactively just
answers the prompt.
Everything it listed ships in 0.1.0, and it described how
setup problems are detected rather than what a user can do.
It was a working doc for the 0.1.0 release and is kept
outside the tree, under the now-ignored .notes/.
axios and form-data were the only two reaching users, bundled
into dist/ through @stellar/stellar-sdk; the rest are dev-only.
Every mocha up to 12.0.0-beta-2 pins vulnerable serialize-javascript,
qs and diff majors, so those go through overrides rather than the
downgrade of the test runner that npm audit suggests.
stellar-xdr and soroban-env-host had no patched version
reachable from soroban-sdk 22. SDK 27 requires the wasm32v1-none
target, which ContractBuilder already auto-detects, so neither code
nor docs needed changing. Verified by tracing increment against a
real komet-node: 1551 records, three source stops, variables
resolving.
It was still in the pre-v0.1.87 shape, so the zero-dependency
replay config that examples/README.md offers as a first step
failed with the stale-node error. The test fixtures had all been
re-recorded; nothing replays this copy, so the suite stayed green.
It is the same 41-record run, identical once `kind` is stripped.
The README offers a recording as a bug report to hand to someone
else, but the source paths come from the wasm debug info and are
absolute, and nothing remaps them.
@RaoulSchaffranek
RaoulSchaffranek merged commit 3f883dd into main Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant